Skip to content

10个颜色块 —— 一步一步用DMA发送像素数据

我们从头梳理,把每一步都讲清楚。


第一步:理解"为什么要用DMA"

你现在的 lcd_fill_color_cpu 是这样发数据的:

CPU → 每次发1字节 → SPI硬件 → 屏幕

240×320 = 76800个像素,每个像素2字节,共 153600次 CPU介入,CPU累死了,速度慢。

用DMA之后:

CPU只需要准备好buffer,告诉DMA"去发这块内存" → DMA自己搬数据 → SPI硬件 → 屏幕
CPU可以去做别的事

第二步:DMA发送像素,有哪些API可以选?

ESP-IDF SPI master 驱动提供这几个发送函数:

函数阻塞?走DMA?适合场景
spi_device_polling_transmit()是,CPU一直等小数据不走DMA命令/参数(1~4字节)
spi_device_transmit()是,等完成才返回会走DMA简单场景,大数据
spi_device_queue_trans(),放入队列就返回会走DMA大数据,后台传输
spi_device_get_trans_result()是,等队列里的事务完成配合queue_trans使用

对于像素数据(大块数据),最常用的组合是:

spi_device_queue_trans()   ← 把任务交给DMA,立刻返回
spi_device_get_trans_result() ← 等DMA传完

或者更简单地直接用:

spi_device_transmit()  ← 内部自动用DMA,等完成

我们这里用 spi_device_transmit() 就够了,最简单。 [SPI master example]


第三步:DMA对内存有要求!

这是最重要的一点,很多小白踩坑的地方:

DMA只能访问特定的内存区域,普通的局部变量(栈上的数组)DMA访问不到!

所以像素buffer必须这样分配:

c
// ❌ 错误:栈上的数组,DMA无法访问
uint16_t buf[240 * 64];

// ✅ 正确:从DMA可访问的堆内存分配
uint16_t *buf = heap_caps_malloc(240 * 64 * sizeof(uint16_t),
                                  MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);

MALLOC_CAP_DMA 表示"我要DMA能访问的内存",MALLOC_CAP_INTERNAL 表示"从芯片内部RAM分配"(速度快)。[SPI master example]


第四步:ST7789的字节序问题(图片转成C数组时注意)

ST7789 接收 RGB565 颜色时,高字节在前(大端)

比如红色 0xF800

  • 高字节 0xF8 要先发
  • 低字节 0x00 后发

但ESP32内存里存的是小端,所以你填buffer时要手动交换高低字节

c
uint16_t color = 0xF800;  // 红色
// 交换字节:高字节和低字节互换
uint16_t color_swapped = (color >> 8) | (color << 8);
// color_swapped = 0x00F8,发出去后屏幕收到的顺序就是 F8 00,正确!

第五步:spi_transaction_t 结构体说明

发送数据时需要填这个结构体,告诉SPI驱动"发什么、发多少":

c
spi_transaction_t t = {
    .length    = 像素字节数 * 8,  // 注意:length单位是【bit】,不是字节!
    .tx_buffer = buf,             // 指向你的像素buffer
};

关键字段:

字段含义
.length要发送的数据长度,单位是bit,所以字节数要×8
.tx_buffer指向发送数据的指针,必须是DMA可访问内存
.tx_data当数据≤4字节时可以直接填在这里,不用单独分配buffer
.flags特殊标志,一般填0即可

代码:DMA模式传送像素数据代码

C
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "driver/spi_master.h"
#include "esp_log.h"

// 引脚定义
#define lcd_spi_cs     GPIO_NUM_10     // CS 片选引脚,当CS拉低时表示SPI从机设备被选中,可以进行数据传输
#define lcd_spi_dc     GPIO_NUM_14     // DC 数据/命令引脚,拉低表示数据,拉高表示命令
#define lcd_spi_rst    GPIO_NUM_13     // RST 复位引脚
#define lcd_spi_bl     GPIO_NUM_9      // BL LED 背光引脚
#define lcd_spi_mosi   GPIO_NUM_11     // MOSI (也是SDA)主输出引脚,串行数据线用于发送数据到屏幕
#define lcd_spi_clk    GPIO_NUM_12     // CLK 时钟引脚
#define spi_host       SPI2_HOST       // ESP32-S3 SPI 主机控制器选择


spi_device_handle_t spi_device_handle;


// 函数声明
void lcd_gpio_init(void);
void st7789_init(spi_device_handle_t spi_device_handle);
void st7789_write_cmd(uint8_t cmd, spi_device_handle_t spi_device_handle);
void st7789_write_data(uint8_t data, spi_device_handle_t spi_device_handle);
void st7789_set_window(uint16_t x0, uint16_t y0, 
                        uint16_t x1, uint16_t y1, 
                        spi_device_handle_t spi_device_handle);
void lcd_fill_color_cpu(uint16_t color, spi_device_handle_t spi_handle);
void lcd_fill_color_dma(uint16_t color, spi_device_handle_t spi_handle);




/**
 * @brief 主函数
 */
void app_main(void)
{
    lcd_gpio_init();


    // SPI bus initialization
    spi_bus_config_t spi_buscfg = {
        .miso_io_num = -1,
        .mosi_io_num = lcd_spi_mosi,
        .sclk_io_num = lcd_spi_clk,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
        .max_transfer_sz = 240 * 320 * 2 + 8,   
        
    };
    esp_err_t ret = spi_bus_initialize(SPI2_HOST, &spi_buscfg, SPI_DMA_CH_AUTO);
    if (ret != ESP_OK) {
        ESP_LOGE("ST7789", "Failed to initialize SPI bus");
        return;
    };

    // 配置SPI设备参数结构体
    spi_device_interface_config_t spi_st7789_dev_cfg = {
        .clock_source = SPI_CLK_SRC_DEFAULT, // 使用默认时钟源
        .clock_speed_hz = 40 * 1000 * 1000,  // SPI时钟频率,根据屏幕规格书,最高可达62.5MHz,但实际使用中可能需要调整以确保稳定性
        .spics_io_num = lcd_spi_cs,          // SPI片选引脚
        .mode = 0,                           // SPI模式,CLK polarity: 0, CLK phase: 0,时钟极性说的是空闲状态时的CLK信号电平
        .queue_size = 7,
    };

    // 将SPI设备添加到SPI总线上
    ret = spi_bus_add_device(SPI2_HOST, &spi_st7789_dev_cfg, &spi_device_handle);
    if (ret != ESP_OK) {
        ESP_LOGE("ST7789", "Failed to add SPI device");
        return;
    }


    // 初始化屏幕
    st7789_init(spi_device_handle);

    // 填充屏幕(0x801F 紫色, 0x07E0 绿色, 0xF800 红色, 0xFFFF 白色)
    // lcd_fill_color_cpu(0x07E0, spi_device_handle);


        // 定义颜色数组(顺序:红黄蓝黑白绿橙粉棕紫)
    uint16_t colors[] = {
        0xF800,  // 红
        0xFFE0,  // 黄
        0x001F,  // 蓝
        0x0000,  // 黑
        0xFFFF,  // 白
        0x07E0,  // 绿
        0xFC00,  // 橙
        0xF81F,  // 粉
        0x9C60,  // 棕
        0x801F   // 紫
    };
    int color_count = sizeof(colors) / sizeof(colors[0]);   // 获取颜色数组的长度
    int index = 0;  // 颜色索引

    // 循环填充颜色(10种颜色)
    while (1) {
        lcd_fill_color_dma(colors[index], spi_device_handle);  // 调用填充颜色函数,发送颜色数据给屏幕)
        index = (index + 1) % color_count;  // 发送完一种颜色后,将索引加1,并取余数,确保索引在颜色数组范围内
        vTaskDelay(pdMS_TO_TICKS(500));  // 延时0.5秒
    }

}










/**
 * @brief GPIO初始化
 */
void lcd_gpio_init(void){
    
    // GPIO配置结构体
    gpio_config_t lcd_gpio_config = {
        .pin_bit_mask = (1ULL << lcd_spi_dc) | (1ULL << lcd_spi_rst) | (1ULL << lcd_spi_bl),
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
        
    };
    // GPIO初始化
    gpio_config(&lcd_gpio_config);

    // 初始化完成后,默认将DC、RST、BL引脚设置为高电平,确保屏幕处于非复位状态并且背光打开
    gpio_set_level(lcd_spi_dc, 1);     // 默认数据模式
    gpio_set_level(lcd_spi_rst, 1);    // 默认复位状态
    gpio_set_level(lcd_spi_bl, 1);     // 默认背光开启

}




/**
 * @brief 发送命令
 *
 * @param cmd 命令
 * @param spi_handle SPI句柄
 */
void st7789_write_cmd(uint8_t cmd, spi_device_handle_t spi_device_handle)
{
    // 引脚拉低,表示command,Page48 D/CX indicates whether the byte is command (D/CX=’0’) 
    // or parameter/RAM data (D/CX=’1’).
    gpio_set_level(lcd_spi_dc, 0);
    // 发送命令
    spi_transaction_t transmit_cmd_config = {
        .tx_buffer = &cmd,
        .length = 8,
    };
    spi_device_polling_transmit(spi_device_handle, &transmit_cmd_config);
}



/**
 * @brief 发送数据
 *
 * @param data 数据
 * @param spi_handle SPI句柄
 */
void st7789_write_data(uint8_t data, spi_device_handle_t spi_device_handle)
{
    // 引脚拉高,表示数据,Page48 D/CX indicates whether the byte is command (D/CX=’0’) 
    // or parameter/RAM data (D/CX=’1’).
    gpio_set_level(lcd_spi_dc, 1);
    // 发送数据
    spi_transaction_t transmit_data_config = {
        .tx_buffer = &data,
        .length = 8,
    };
    spi_device_polling_transmit(spi_device_handle, &transmit_data_config);
}












/**
 * @brief SPI初始化,配置SPI总线参数并添加SPI设备
 * 
 */
void st7789_init(spi_device_handle_t spi_device_handle){ 
    // 1, 硬件复位,拉低RST引脚
    // 屏幕初始化代码
    gpio_set_level(lcd_spi_rst, 0);     // 硬件复位,拉低RST引脚,默认高电平
    // 延时10ms,Page42 ↓
    // 原文 7. It is necessary to wait 5msec after releasing RESX before sending commands. Also Sleep Out command cannot be sent for 120msec.
    vTaskDelay(pdMS_TO_TICKS(10));

    gpio_set_level(lcd_spi_rst, 1);     // 硬件复位,拉高,恢复高电平
    vTaskDelay(pdMS_TO_TICKS(120));     
    // 延时120ms,Page42 ↓
    // 原文 3. During the Resetting period, the display will be blanked 
    // (The display is entering blanking sequence, which maximum time is 120 ms, when Reset Starts in Sleep Out –mode. 
    
    // 2, 软件复位,发送0x01命令
    // 发送软件复位命令0x01,Page124
    st7789_write_cmd(0x01, spi_device_handle);
    vTaskDelay(pdMS_TO_TICKS(120));

    // 3, 退出睡眠模式,发送0x11命令
    st7789_write_cmd(0x11, spi_device_handle);
    vTaskDelay(pdMS_TO_TICKS(10));     // 从睡眠模式退出后并立即再次进入睡眠模式要等120毫秒,而退出后发送其他新命令要等待至少5毫秒

    // 4, 设置像素格式,发送0x3A命令,参数0x55表示16位颜色以及262K,Page185,
    st7789_write_cmd(0x3A, spi_device_handle);
    st7789_write_data(0x55, spi_device_handle);

    // 5,设置显示方向,发送0x36命令,参数0x00表示竖屏、RGB模式,Page176
    st7789_write_cmd(0x36, spi_device_handle);
    st7789_write_data(0x00, spi_device_handle);

    // 6, 设置颜色反转开启 0x21
    st7789_write_cmd(0x21, spi_device_handle);

    // 7,设置display on,发送0x29命令,Page124
    st7789_write_cmd(0x29, spi_device_handle);

    vTaskDelay(pdMS_TO_TICKS(100));    // 等待100ms,确保屏幕稳定显示

    ESP_LOGI("ST7789", "ST7789 init success!");
}




/**
 * @brief 设置 ST7789 的显示窗口(列/行地址范围)
 * @param x0 起始列(0~239)
 * @param y0 起始行(0~319)
 * @param x1 结束列(0~239)
 * @param y1 结束行(0~319)
 * @param spi_handle SPI 设备句柄
 */
void st7789_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, spi_device_handle_t spi_handle)
{
    // ---- 设置列地址(X 方向) ----
    st7789_write_cmd(0x2A, spi_handle);          // 告诉屏幕:接下来我要发列地址参数

    // 发送起始列(高字节,低字节)
    st7789_write_data((x0 >> 8) & 0xFF, spi_handle); // 起始列的高 8 位
    st7789_write_data(x0 & 0xFF, spi_handle);        // 起始列的低 8 位

    // 发送结束列(高字节,低字节)
    st7789_write_data((x1 >> 8) & 0xFF, spi_handle); // 结束列的高 8 位
    st7789_write_data(x1 & 0xFF, spi_handle);        // 结束列的低 8 位

    // ---- 设置行地址(Y 方向) ----
    st7789_write_cmd(0x2B, spi_handle);          // 告诉屏幕:接下来我要发行地址参数

    // 发送起始行(高字节,低字节)
    st7789_write_data((y0 >> 8) & 0xFF, spi_handle);
    st7789_write_data(y0 & 0xFF, spi_handle);

    // 发送结束行(高字节,低字节)
    st7789_write_data((y1 >> 8) & 0xFF, spi_handle);
    st7789_write_data(y1 & 0xFF, spi_handle);
}





/**
 * @brief 填充整个屏幕为指定颜色(RGB565)
 * @param color 16位颜色值
 * @param spi_device_handle SPI设备句柄
 */
void lcd_fill_color_cpu(uint16_t color, spi_device_handle_t spi_device_handle)
{
    // 1. 设置全屏窗口 (0,0) ~ (239,319)
    st7789_set_window(0, 0, 239, 319, spi_device_handle);

    // 2. 发送写内存命令 (0x2C)
    st7789_write_cmd(0x2C, spi_device_handle);

    // 3. 计算像素总数
    uint32_t pixel_count = 240 * 320;  // 76800 个像素

    // 4. 逐个字节发送颜色数据(每个像素2字节)
    uint8_t color_high = (color >> 8) & 0xFF;
    uint8_t color_low  = color & 0xFF;

    for (uint32_t i = 0; i < pixel_count; i++) {
        st7789_write_data(color_high, spi_device_handle);
        st7789_write_data(color_low, spi_device_handle);
    }
}


/**
 * @brief 填充整个屏幕为指定颜色(DMA)
 * @param color 16位颜色值
 * @param spi_device_handle SPI设备句柄
 */

void lcd_fill_color_dma(uint16_t color, spi_device_handle_t spi_handle)
{
    // 第1步:设置全屏窗口
    st7789_set_window(0, 0, 239, 319, spi_handle);

    // 第2步:发送写内存命令
    st7789_write_cmd(0x2C, spi_handle);

    // 第3步:DC 引脚拉高(数据模式)
    gpio_set_level(lcd_spi_dc, 1);

    // 第4步:申请一块 DMA 内存作为"快递车"
    // 不能一次发整屏,硬件有单次传输上限,所以分块发送
    int chunk_size = 4096; // 每次发 4096 字节(约 2048 个像素)
    uint8_t *buf = heap_caps_malloc(chunk_size, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
    if (buf == NULL) {
        ESP_LOGE("ST7789", "DMA内存申请失败!");
        return;
    }

    // 第5步:把颜色数据填满缓冲区
    uint8_t hi = (color >> 8) & 0xFF;
    uint8_t lo = color & 0xFF;
    for (int i = 0; i < chunk_size; i += 2) {
        buf[i]     = hi;
        buf[i + 1] = lo;
    }

    // 第6步:循环分块发送,直到整屏填满
    int total = 240 * 320 * 2; // 153600 字节
    int sent  = 0;

    while (sent < total) {
        // 计算本次发多少字节(最后一块可能不足 4096)
        int this_time = total - sent;
        if (this_time > chunk_size) this_time = chunk_size;

        spi_transaction_t t = {
            .tx_buffer = buf,
            .length    = this_time * 8, // 单位是 bit,所以 ×8
        };
        spi_device_transmit(spi_handle, &t);

        sent += this_time;
    }

    // 第7步:释放内存
    heap_caps_free(buf);
}




// void lcd_fill_color_dma(uint16_t color, spi_device_handle_t spi_handle)
// {
//     st7789_set_window(0, 0, 239, 319, spi_handle);
//     st7789_write_cmd(0x2C, spi_handle);
//     gpio_set_level(lcd_spi_dc, 1);

//     // chunk_size 最大设为 32768(ESP32-S3 硬件单次 DMA 上限)
//     int chunk_size = 32768;
//     uint8_t *buf = heap_caps_malloc(chunk_size, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
//     if (buf == NULL) {
//         ESP_LOGE("ST7789", "DMA内存申请失败!");
//         return;
//     }

//     uint8_t hi = (color >> 8) & 0xFF;
//     uint8_t lo = color & 0xFF;
//     for (int i = 0; i < chunk_size; i += 2) {
//         buf[i]     = hi;
//         buf[i + 1] = lo;
//     }

//     int total = 240 * 320 * 2; // 153600 字节
//     int sent  = 0;
//     while (sent < total) {
//         int this_time = total - sent;
//         if (this_time > chunk_size) this_time = chunk_size;

//         spi_transaction_t t = {
//             .tx_buffer = buf,
//             .length    = this_time * 8,
//         };
//         spi_device_transmit(spi_handle, &t);
//         sent += this_time;
//     }

//     heap_caps_free(buf);
// }

代码注释详细版:第四步到第六步

好的!我们把这段代码拆成每一个小部分,逐一详细解释。


第4步:申请 DMA 内存

c
int chunk_size = 4096;
uint8_t *buf = heap_caps_malloc(chunk_size, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
uint8_t *buf 是什么?
uint8_t   → 每个格子存 1 个字节(8位)的数据类型
*         → 这是一个指针,buf 存的是内存地址(指向仓库的门牌号)
buf       → 变量名,代表这块内存的起始地址

形象理解:
buf 就像一个"仓库地址",
heap_caps_malloc 帮你建好仓库后,
把仓库的门牌号告诉 buf
heap_caps_malloc 申请到的内存长什么样?
内存地址(buf指向这里)

┌────┬────┬────┬────┬────┬────┬──────┬────┐
│    │    │    │    │    │    │ .... │    │
└────┴────┴────┴────┴────┴────┴──────┴────┘
 [0]  [1]  [2]  [3]  [4]  [5]  ....  [4095]
←────────────── 4096 个格子 ──────────────→
每个格子存 1 字节
if (buf == NULL) 是什么?
heap_caps_malloc 申请成功 → 返回内存地址 → buf 不是 NULL
heap_caps_malloc 申请失败 → 返回 NULL   → buf 是 NULL

NULL 就是"空",表示申请失败,内存不够用
所以这里做一个检查,失败了就打印错误并退出

第5步:填充颜色数据

c
uint8_t hi = (color >> 8) & 0xFF;
uint8_t lo = color & 0xFF;
为什么要拆成 hilo

颜色是 RGB565 格式,16位,比如红色 0xF800

0xF800 用二进制表示:
1111 1000 0000 0000
←── 高8位 ──→←── 低8位 ──→
    hi=0xF8       lo=0x00

SPI 每次只能发 1 个字节,
所以必须拆成两个字节分开发
>> 8& 0xFF 是什么意思?
color = 0xF800

color >> 8  → 把所有位向右移动8位
  0xF800 → 0x00F8
  取到了高8位

& 0xFF  → 只保留最低8位,其余清零
  0x00F8 & 0xFF = 0xF8  ← 确保只有8位

所以:
hi = (0xF800 >> 8) & 0xFF = 0xF8  (高字节)
lo =  0xF800       & 0xFF = 0x00  (低字节)
for 循环填充
c
for (int i = 0; i < chunk_size; i += 2) {
    buf[i]     = hi;
    buf[i + 1] = lo;
}
i 每次跳 2 格(因为每个像素占 2 字节):

buf[0]=0xF8  buf[1]=0x00  → 第1个像素(红色)
buf[2]=0xF8  buf[3]=0x00  → 第2个像素(红色)
buf[4]=0xF8  buf[5]=0x00  → 第3个像素(红色)
...
buf[4094]=0xF8  buf[4095]=0x00  → 第2048个像素

整个仓库(4096字节)全部填满了同一种颜色

第6步:循环分块发送

c
int total = 240 * 320 * 2; // 153600 字节
int sent  = 0;
total = 整个屏幕需要发的总字节数
sent  = 已经发出去了多少字节(初始为0)
while 循环逻辑
第1趟:
  sent = 0
  total - sent = 153600 - 0 = 153600
  153600 > 4096,所以 this_time = 4096
  发送 4096 字节
  sent = 0 + 4096 = 4096

第2趟:
  sent = 4096
  total - sent = 153600 - 4096 = 149504
  149504 > 4096,所以 this_time = 4096
  发送 4096 字节
  sent = 4096 + 4096 = 8192

... 重复 ...

最后一趟(第38趟):
  sent = 151552
  total - sent = 153600 - 151552 = 2048
  2048 < 4096,所以 this_time = 2048  ← 不足一整块,发剩余的
  发送 2048 字节
  sent = 151552 + 2048 = 153600

sent == total,循环结束,整屏填满!
spi_transaction_t t 是什么?
c
spi_transaction_t t = {
    .tx_buffer = buf,
    .length    = this_time * 8,
};
这是一张"快递单",告诉 SPI 硬件:

tx_buffer = buf    → 货物在哪里(DMA仓库的地址)
length = this_time * 8 → 发多少(单位是bit,所以字节数×8)

比如 this_time = 4096 字节:
length = 4096 × 8 = 32768 bit
spi_device_transmit 做了什么?
spi_device_transmit(spi_handle, &t)

把"快递单 t"交给 SPI 驱动

SPI 驱动通知 DMA:"去 buf 地址,搬 this_time 字节的货"

DMA 自动搬运数据 → SPI 硬件 → 屏幕

搬完后函数返回,继续下一趟 while 循环

总结:第四步到第六步

申请4096字节仓库(buf)

把颜色数据填满仓库(2048个像素)

┌─────────────────────────────┐
│ while 循环(约38趟)         │
│                             │
│  计算本趟发多少字节          │
│         ↓                   │
│  填好快递单(spi_transaction)│
│         ↓                   │
│  DMA搬运 → SPI → 屏幕       │
│         ↓                   │
│  sent += this_time          │
└─────────────────────────────┘

sent == 153600,整屏填满!

heap_caps_free(buf) 释放仓库

整体流程总结

1. heap_caps_malloc()     → 分配DMA可访问的buffer
2. 填充颜色值(注意字节交换)
3. st7789_set_window()    → 告诉屏幕"写哪个区域"
4. st7789_write_cmd(0x2C) → 发"写内存"命令
5. spi_device_transmit()  → DMA把buffer数据发给屏幕
6. 重复3~5,共10次
7. heap_caps_free()       → 释放buffer

理解了这个流程之后,第二个需求(循环刷屏)只是在外面套一个 while(1) 循环,逻辑完全一样,随时可以继续。

觉醒,然后燎原。 © 2026 门主引擎